<?php
/**
 * @see Alinks_Cacher_Interface
 */
require_once('Alinks/Cacher/Interface.php');

/**
 * Used to cache content to files
 *
 * Copyright (C) 2008  Sean Hickey
 * 
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * @author Sean Hickey <sean@headzoo.com>
 * @version $Id: File.php 387 2008-07-03 05:35:19Z sean $
 * @package Alinks_Cacher
 */
class Alinks_Cacher_File implements Alinks_Cacher_Interface
{
	/**
	 * Code placed at the top of all cache files
	 */
	const TOP = "<?php die(); ?>\r\n";
	
	/**
	 * Index array key for expiration
	 */
	const EXPIRATION = 'expiration';
	
	/**
	 * Index array key for when the file was created
	 */
	const CREATED = 'created';
	
	/**
	 * The cache options
	 *
	 * @var array
	 */
	protected $_options = array();
	
	/**
	 * Constructor
	 *
	 * @param array $options The cache options
	 */
	public function __construct(array $options)
	{
		$this->setOptions($options);
	}
	
	/**
	 * Sets the caching options
	 *
	 * @param array $options The options
	 * @return Alinks_Cacher_File
	 * @throws Alinks_Cacher_Exception
	 */
	public function setOptions(array $options)
	{
		if (!isset($options['dir'])) {
			/**
			 * @see Alinks_Cacher_Exception
			 */
			require_once('Alinks/Cacher/Exception.php');
			throw new Alinks_Cacher_Exception("The options must contain a 'dir' index with the directory to store cached content");
		}
		$options['dir'] = rtrim($options['dir'], '/\\');
		if (!is_writable($options['dir'])) {
			/**
			 * @see Alinks_Cacher_Exception
			 */
			require_once('Alinks/Cacher/Exception.php');
			throw new Alinks_Cacher_Exception('The directory ' . $options['dir'] . ' is not writable');
		}
		
		$this->_options = $options;
		return $this;
	}
	
	/**
	 * Returns the caching options
	 *
	 * @return array
	 */
	public function getOptions()
	{
		return $this->_options;
	}
	
	/**
	 * Returns if cached content exists for the given key
	 *
	 * @param string $key The cache key
	 * @param string $namespace The cache namespace
	 * @return bool
	 */
	public function exists($key, $namespace)
	{
		$key = $this->_clean($key);
		$namespace = $this->_clean($namespace);
		$index = $this->_getNamespaceIndex($namespace);
		
		$exists = false;
		if (isset($index[$key])) {
			$expiration = $index[$key][self::EXPIRATION];
			$created = $index[$key][self::CREATED];
			if (0 != $expiration) { // 0 = doesn't expire
				if ($created + $expiration > time()) {
					$exists = true;
				}
			} else {
				$exists = true;
			}
		}
		
		/**
		 * Doesn't matter if the file is listed in the index, if the
		 * cache file doesn't actually exist. If the file doesn't exist
		 * but is listed in the index, then something is wrong. We will
		 * flush the whole namespace to clean things up.
		 */
		if ($exists && !file_exists($this->_options['dir'] . "/$namespace/$key.php")) {
			$this->flush($namespace);
			$exists = false;
		}
		
		return $exists;
	}
	
	/**
	 * Sets content to be cached
	 *
	 * @param string $key The cache key
	 * @param string $namespace The cache namespace
	 * @param mixed $value The cache value
	 * @param int $expires Number of seconds to keep the value cached
	 * @return Alinks_Cacher_File
	 */
	public function set($key, $namespace, $value, $expires = 0)
	{
		$key = $this->_clean($key);
		$namespace = $this->_clean($namespace);
		
		$index = $this->_getNamespaceIndex($namespace);
		$index[$key][self::CREATED] = time();
		$index[$key][self::EXPIRATION] = $expires;
		
		$file = $this->_options['dir'] . "/$namespace/$key.php";
		if (!file_put_contents($file, $this->_serialize($value))) {
			/**
			 * @see Alinks_Cacher_Exception
			 */
			require_once('Alinks/Cacher/Exception.php');
			throw new Alinks_Cache_Exception("Could not write to the file $file");
		}
		
		$this->_setNamespaceIndex($namespace, $index);
		return $this;
	}
	
	/**
	 * Returns cached content
	 *
	 * Throws a Alinks_Cacher_Exception if no cache exists for the
	 * given key and namspace. Use exists() first to be sure the cached
	 * content exists.
	 *
	 * @param string $key The cache key
	 * @param string $namespace The cache namespace
	 * @return mixed
	 */
	public function get($key, $namespace)
	{
		$key = $this->_clean($key);
		$namespace = $this->_clean($namespace);
		if (!$this->exists($key, $namespace)) {
			/**
			 * @see Alinks_Cacher_Exception
			 */
			require_once('Alinks/Cacher/Exception.php');
			throw new Alinks_Cacher_Exception("No cache exists for $key in namespace $namespace");
		}
		
		$file = $this->_options['dir'] . "/$namespace/$key.php";
		return $this->_unserialize(file_get_contents($file));
	}
	
	/**
	 * Deletes any cached content for the given key and namespace
	 *
	 * Returns true if the cache was deleted, false if not. False doesn't
	 * mean an error. It could just mean there was no cache to delete.
	 *
	 * @param string $key The cache key
	 * @param string $namespace The cache namepsace
	 * @return bool
	 */
	public function delete($key, $namespace)
	{
		$key = $this->_clean($key);
		$namespace = $this->_clean($namespace);
		if (!$this->exists($key, $namespace)) {
			return false;
		}
		
		$index = $this->_getNamespaceIndex($namespace);
		unset($index[$key]);
		
		$file = $this->_options['dir'] . "/$namespace/$key.php";
		if (!@unlink($file)) {
			/**
			 * @see Alinks_Cacher_Exception
			 */
			require_once('Alinks/Cacher/Exception.php');
			throw new Alinks_Cacher_Exception("Could not delete the file $file");
		}
		
		$this->_setNamespaceIndex($namespace, $index);
		return true;
	}
	
	/**
	 * Flushes the cache for a specific namespace
	 *
	 * This method always returns true.
	 *
	 * @param string $namespace The cache namespace
	 * @return bool
	 */
	public function flush($namespace = null)
	{
		if (null === $namespace) {
			return $this->_flushAll();
		}
		
		$index = $this->_getNamespaceIndex($namespace);
		$keys = array_keys($index);
		foreach($keys as $key) {
			$this->delete($key, $namespace);
		}
		
		return true;
	}
	
	/**
	 * Flushes out the entire cache
	 *
	 * This method always returns true.
	 *
	 * @return bool
	 */
	protected function _flushAll()
	{
		$iterator = new DirectoryIterator($this->_options['dir']);
		foreach($iterator as $dir) {
			if ($dir->isDot() || $dir->isFile()) {
				continue;
			}
			$this->flush($dir->getFilename());
		}
		return true;
	}
	
	/**
	 * Removes any special characters from the string
	 *
	 * @param string $string The string
	 * @return string
	 */
	protected function _clean($string)
	{
		return preg_replace('/([^0-9A-Z_])/i', '_', $string);
	}
	
	/**
	 * Returns the values form the index file for the given namespace
	 *
	 * @param string $namespace The namespace
	 * @return array
	 */
	protected function _getNamespaceIndex($namespace)
	{
		$namespace = $this->_clean($namespace);
		$index = $this->_createIndex($this->_options['dir'] . "/$namespace");
		return $this->_unserialize(file_get_contents($index));
	}
	
	/**
	 * Writes index values to an index
	 *
	 * Returns the name of the index file that was written to.
	 *
	 * @param string $namespace The cache namespace
	 * @param array $values The index values
	 * @return string
	 */
	protected function _setNamespaceIndex($namespace, array $values)
	{
		$namespace = $this->_clean($namespace);
		$index = $this->_createIndex($this->_options['dir'] . "/$namespace");
		file_put_contents($index, $this->_serialize($values));
		return $index;
	}
	
	/**
	 * Creates a cache registry index in the given directory
	 *
	 * Returns the full path and name of the created index. This method does
	 * nothing if the index already exists.
	 *
	 * @param string $dir The directory
	 * @return string
	 * @throws Alinks_Cache_Exception
	 */
	protected function _createIndex($dir)
	{
		$dir = rtrim($dir, '/\\');
		$index = "$dir/index.php";
		if (!file_exists($index)) {
			if (!file_exists($dir)) {
				if (!@mkdir($dir) || !is_writable($dir)) {
					/**
					 * @see Alinks_Cacher_Exception
					 */
					require_once('Alinks/Cacher/Exception.php');
					throw new Alinks_Cacher_Exception("Could not create or write to the directory $dir");
				}
			}
			file_put_contents($index, $this->_serialize(array()));
		}
		
		return $index;
	}
	
	/**
	 * Serializes content to be written to a cache file
	 *
	 * @param mixed $content The content to serialize
	 * @return string
	 */
	protected function _serialize($content)
	{
		return self::TOP . serialize($content);
	}
	
	/**
	 * Unserializes content that came from a cache file
	 *
	 * @param string $content The content from the cache file
	 * @return mixed
	 */
	protected function _unserialize($content)
	{
		$parts = explode("\r\n", $content, 2);
		return unserialize($parts[1]);
	}
}